""" Implements the point classes for sbak.geometry.

    This module should be considered an "implementation detail" and should not
    be accessed directly.
"""

import operator
from math import pi, atan2, hypot, sin, cos, degrees, radians

__all__ = [
    'AbstractPoint',
    'Point',
    'FPoint',
    'FrozenPointMixin',
    'FrozenPoint',
    'FrozenFPoint',
]


class AbstractPoint(object):
    """ Base for 2-dimensional point classes.
    
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        NOTE:
            
            This class is only a base. It has a method called `conv`
            which must be overridden by a subclass in order for the class to
            work properly. `conv` should be a callable that accepts one
            argument and returns that argument converted to a certain numeric
            type, such as int or float. (The `int` and `float` builtins work
            fine for this purpose.)
            
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    
        All AbstractPoint-based point objects can be made in three different
        ways:
        
        1.) To create a point based on ordinary cartesian coordinates, call
            the class as you would any other class:
        
        Point() -> Point(0,0)
        Point(v) -> Point(v.x, v.y)
        Point(x, y) -> Point(x,y)
        Point((x, y)) -> Point(x,y)
        
        2.) To create a point based on polar coordinates with an angle
            measured in degrees, use the class method `Point.from_degmag`:
        
        Point.from_degmag(v) -> Point(APX_X, APX_Y)
        Point.from_degmag(deg, mag) -> Point(APX_X, APX_Y)
        Point.from_degmag((deg, mag)) -> Point(APX_X, APX_Y)
        
        3.) To create a point based on polar coordinates with an angle
            measured in radians, use the class method `Point.from_radmag`:
        
        Point.from_radmag(v)
        Point.from_radmag(rad, mag) -> Point(APX_X, APX_Y)
        Point.from_radmag((rad, mag)) -> Point(APX_X, APX_Y)
        
        See `sbak.geometry` for more information about vectors.
    """
    
    __slots__ = ['_x', '_y', '_rad', '_deg', '_mag',
                 '_xy', '_degmag', '_radmag']
    
    def __init__(self, x = None, y = None):
        """ Initialize a point using cartesian coordinates.
        
            Point() -> Point(0,0)
            Point(v) -> Point(v.x, v.y)
            Point(x, y) -> Point(x,y)
            Point((x, y)) -> Point(x,y)
        """
        
        self._x = self._y = self._deg = self._mag = self.conv(0)
        self._xy, self._degmag = (self.conv(0),)*2
        
        # Radians is always a float.
        self._rad = 0.0
        self._radmag = 0.0, self.conv(0)
        
        if x is not None:
            try:
                self._x, self._y = self._xy = x, y = tuple(map(self.conv,
                                                               x[:2]))
            except TypeError:
                if y is not None:
                    self._x, self._y = self._xy = x, y = tuple(map(self.conv,
                                                                   (x, y)))
                    self.__update_polar(x, y)
                    return  # small optimization; does not apply to other
                            # constructors.
                else:
                    raise TypeError("%s(x,y) -- `x` given, but not `y`." %
                                    self.__class__.__name__)
            else:
                self.__update_polar(x, y)
                return  # small optimization; does not apply to other
                        # constructors.
        
        if y is not None:
            raise TypeError("%s(x,y) -- `y` given, but not `x`." %
                                    self.__class__.__name__)
    
    @classmethod
    def from_degmag(cls, deg, mag = None):
        """ Create a new point from a degree angle and magnitude.
        
            Point.from_degmag(v) -> Point(APX_X, APX_Y)
            Point.from_degmag(deg, mag) -> Point(APX_X, APX_Y)
            Point.from_degmag((deg, mag)) -> Point(APX_X, APX_Y)
        """
        self = cls()

        try:
            self._deg, self._mag = self._degmag = deg, mag = tuple(
                                                    map(self.conv, deg[:2]))
        except TypeError:
            if mag is not None:
                self._deg, self._mag = self._degmag = deg, mag = tuple(
                                                    map(self.conv, (deg,mag)))
                self._rad = self.conv(radians(deg))
                self._radmag = rad, mag
                self.__update_cartesian(rad, mag)
            else:
                raise TypeError("%s.from_degmag(deg,mag) -- `deg` given,"
                                " but not `mag`." %
                                self.__class__.__name__)
        else:
            self._rad = self.conv(radians(deg))
            self._radmag = rad, mag
            self.__update_cartesian(rad, mag)
        
        return self
    
    @classmethod
    def from_radmag(cls, rad, mag = None):
        """ Create a new point form a radian angle and magnitude.
        
            Point.from_radmag(v)
            Point.from_radmag(rad, mag) -> Point(APX_X, APX_Y)
            Point.from_radmag((rad, mag)) -> Point(APX_X, APX_Y)
        """
        self = cls()
        
        try:
            self._rad, self._mag = self._radmag = rad, mag = tuple(
                                                    map(self.conv, rad[:2]))
        except TypeError:
            if mag is not None:
                self._rad, self._mag = self._radmag = rad, mag = tuple(
                                                    map(self.conv, (rad, mag)))
                self._deg = self.conv(degrees(rad))
                self._degmag = deg, mag
                self.__update_cartesian(rad, mag)
            else:
                raise TypeError("%s.from_radmag(rad,mag) -- `rad` given,"
                                " but not `mag`." %
                                self.__class__.__name__)
        else:
            self._deg = self.conv(degrees(rad))
            self._degmag = deg, mag
            self.__update_cartesian(rad, mag)
        
        return self
    
    def __repr__(self):
        return '%s(%r, %r)' % (self.__class__.__name__, self.x, self.y)
    
    @staticmethod
    def conv(val):
        """ Dummy method; should be overridden by a subclass. """
        raise NotImplementedError("This method should have been overridden by "
                                  "a subclass!")
    
    x = property(operator.attrgetter('_x'), doc =
        """ The horizontal component. """)
    @x.setter
    def x(self, val):
        self._x = x = self.conv(val)
        self._xy = x, self.y
        self.__update_polar(x, self.y)
    
    y = property(operator.attrgetter('_y'), doc=
        """ The vertical component.""")
    @y.setter
    def y(self, val):
        self._y = y = self.conv(val)
        self._xy = self.x, y
        self.__update_polar(self.x, y)
    
    xy = property(operator.attrgetter("_xy"), doc =
        """ x and y as a tuple. """)
    @xy.setter
    def xy(self, val):
        self._xy = self._x, self._y = tuple(map(self.conv, val))
        self.__update_polar(*self.xy)
    
    rad = property(operator.attrgetter("_rad"), doc =
        """ The angle (in radians).""")
    @rad.setter
    def rad(self, rad):
        mag = self.mag
        self._rad = rad = float(rad) # Radians are always float.
        self._radmag = rad, mag
        self._deg = deg = self.conv(degrees(rad))
        self._degmag = deg, mag
        self.__update_cartesian(rad, mag)
    
    deg = property(operator.attrgetter("_deg"), doc =
        """ The angle (in degrees).""")
    @deg.setter
    def deg(self, deg):
        mag = self.mag
        self._deg = deg = self.conv(deg)
        self._degmag = deg, mag
        self._rad = rad = radians(deg) # Radians are always float.
        self._radmag = rad, mag
        self.__update_cartesian(rad, mag)
    
    mag = property(operator.attrgetter("_mag"), doc =
        """ The magnitude.""")
    @mag.setter
    def mag(self, mag):
        self._mag = mag = self.conv(mag)
        self.__update_cartesian(self.rad, mag)
    
    radmag = property(operator.attrgetter("_radmag"), doc =
        """ Magnitude and radian angle as a tuple. """)
    @radmag.setter
    def radmag(self, val):
         # Radians are always float.
        self._radmag = self._rad, self._mag = rad, mag = (float(val[0]),
                                                          self.conv(val[1]))
        self._deg = deg = self.conv(degrees(rad))
        self._degmag = deg, mag
        self.__update_cartesian(rad, mag)
    
    degmag = property(operator.attrgetter("_degmag"), doc =
        """ Magnitude and angle (in degrees) as a tuple. """)
    @degmag.setter
    def degmag(self, val):
        self._degmag = self._deg, self._mag = deg, mag = tuple(map(self.conv, val[:2]))
        self._rad = rad = radians(deg) # Radians are always float
        self._radmag = rad, mag
        self.__update_cartesian(rad, mag)
    
    def __update_cartesian(self, rad, mag):
        # Updates internal cartesian coordinates from a rad, mag pair.
        
        self._x, self._y = self._xy = cos(rad) * mag, -sin(rad) * mag
        
    def __update_polar(self, x, y):
        # Updates internal polar coordinates from a x, y pair.
        # If x and y are both 0 then nothing happens.
        
        if x or y:
            self._mag = mag = self.conv(hypot(x, y))
            self._rad = rad = atan2(-self.y, self.x) # Radians are always float
            self._radmag = rad, mag
            self._deg = deg = self.conv(degrees(rad))
            self._degmag = deg, mag
    
    def distance_to(self, *vargs):
        """ Return the distance from this point to another.
        
            self.distance_to(x, y) -> distance
            self.distance_to((x, y)) -> distance
            
            The value returned is always positive or 0.
        """
        return abs((self.__class__(*vargs) - self).mag)
    
    def degrees_to(self, *vargs):
        """ Rerturn the angle (in degrees) from this point to another.
        
            self.degrees_to(v) -> degree_angle
            self.degrees_to(x, y) -> degree_angle
            self.degrees_to((x, y)) -> degree_angle
            
            The value returned will be in the range 0 to 359.
        """
        return (self.__class__(*vargs) - self).deg % 360
    
    def radians_to(self, *vargs):
        """ Rerturn the angle (in radians) from this point to another.
        
            self.degrees_to(v) -> radian_angle
            self.radians_to(x, y) -> radian_angle
            self.radians_to((x, y)) -> radian_angle
            
            The value returned will be in the range 0 to pi*2.
        """
        return (self.__class__(*vargs) - self).rad % (pi * 2)
    
    def __iter__(self):
        for v in self.xy:
            yield v
    
    def __len__(self):
        return 2
    
    def __getitem__(self, index):
        return self.xy[index]
    
    def __setitem__(self, index, value):
        if index == 0:
            self.x = value
        elif index == 1:
            self.y = value
        else:
            raise IndexError("index value must be 0 or 1. (Got %r)" %
                             value)
    
    def __add__(self, other):
        return self.__class__(self.x+other[0], self.y+other[1])
    
    def __iadd__(self, other):
        self._x += other[0]
        self._y += other[1]
        self._xy = self._x, self._y
        self.__update_polar(*self.xy)
        return self
    
    def __sub__(self, other):
        return self.__class__(self.x-other[0], self.y-other[1])
    
    def __isub__(self, other):
        self._x -= other[0]
        self._y -= other[1]
        self._xy = self._x, self._y
        self.__update_polar(*self.xy)
        return self
    
    def __mul__(self, val):
        return self.__class__(self.x*val, self.y*val)
    
    def __imul__(self, val):
        self._x *= val
        self._y *= val
        self._xy = self._x, self._y
        self.__update_polar(*self.xy)
        return self
    
    def __div__(self,val):
        return self.__class__(self.x/val, self.y/val)
    
    def __idiv__(self, val):
        self._x /= val
        self._y /= val
        self._xy = self._x, self._y
        self.__update_polar(*self.xy)
        return self

    def __eq__(self, other):
        try:
            return self.x, self.y == tuple(other)
        except TypeError:
            return False

    def __nonzero__(self):
        return self.x or self.y

class Point(AbstractPoint):
    """ Point class with integer precision.
    
        Uses python's `int` builtin as the conversion method.
    
        See AbstractPoint for more information.
    """
    
    conv = int
    
    def freeze(self):
        return FrozenPoint(self)

class FPoint(AbstractPoint):
    """ Point class with floating-point precision.
    
        Uses python's `float` builtin as the conversion method.
    
        See AbstractPoint for more information.
    """
    
    conv = float
    
    def freeze(self):
        return FrozenFPoint(self)

class FrozenPointMixin(object):
    """ Mixin for making immutable, hashable point classes.
    
        Use as follows:
        
        class FrozenPoint(FrozenPointMixin, Point):
            pass
            
        FIXME: FrozenVectors cannot be created using the from_* methods because
        internals will be locked.
    """
    
    # Default value; overridden in __init__.
    __immutable = False
    
    def __init__(self,x,y=None):
        super(FrozenPointMixin,self).__init__(x,y)
        
        # This will prevent any alterations after the initialization, thus
        # preserving the hash value of the vector (which is why it's frozen in
        # the first place.
        self.__immutable = True
    
    def __setitem__(self,index,val):
        raise TypeError("'%s' object is immutable." %
                        self.__class__.__name__)
    
    def __setattr__(self,attr,val):
        if self.__immutable:
            raise AttributeError("'%s' object is immutable." % self.__class__.__name__)
        else:
            super(FrozenPointMixin,self).__setattr__(attr,val)
    
    def __iadd__(self, val):
        return self + val

    def __isub__(self, val):
        return self - val

    def __imul__(self, val):
        return self * val
    
    def __idiv__(self, val):
        return self * val
    
    def __hash__(self):
        return hash(self.xy)

class FrozenPoint(FrozenPointMixin, Point):
    """ Immutable, hashable version of Point. """

class FrozenFPoint(FrozenPointMixin, FPoint):
    """ Immutable, hashable version of FPoint. """